package com.server;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.channels.spi.SelectorProvider;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

import org.apache.log4j.Logger;

import com.common.utilities.ChangeRequest;
import com.common.utilities.ChangeRequestType;
import com.game.ServerGameWorker;

/**
 * User: wattsc5
 * <p/>
 * Date: 2/23/12
 * <p/>
 */
public class NioServer implements Runnable
{
	private static final Logger logger = Logger.getLogger(NioServer.class);

	private int port;

	// The channel on which we will accept connections.
	private ServerSocketChannel serverChannel;

	// The selector we will monitor
	private Selector selector;

	private static final int MaxBufferSize = 1024;

	private final ByteBuffer readBuffer = ByteBuffer.allocate(MaxBufferSize);

	private final List<ChangeRequest> changeRequests = new LinkedList<ChangeRequest>();

	private final Map<SocketChannel, List<ByteBuffer>> pendingData = new HashMap<SocketChannel, List<ByteBuffer>>();

	private ServerGameWorker gameWorker;

	/**
	 * Initializes a new instance of a com.ServerMain.
	 *
	 * @param port the port in which the server will listen on.
	 */
	public NioServer(int port)
	{
		this.port = port;

		this.selector = this.initializeSelector();
		if (this.selector == null)
		{
			this.shutdown("Selector was not initialized successfully.");
		}
	}

	public void setWorker(ServerGameWorker worker)
	{
		this.gameWorker = worker;
	}

	public void shutdown(String message)
	{
		logger.info("Server shutting down - " + message);
		System.exit(0);
	}

	/**
	 * This will start the server.
	 */
	public void run()
	{
		logger.info("Server started on port : " + port);

		// the main server loop
		while (true)
		{
			// Block and wait for a connection.
			try
			{
				// Process any pending changes
				synchronized (this.changeRequests)
				{
					Iterator changes = this.changeRequests.iterator();

					while (changes.hasNext())
					{
						ChangeRequest change = (ChangeRequest) changes.next();
						switch (change.getRequestType())
						{
							case ChangeOps:
								SelectionKey key = change.getSocket().keyFor(this.selector);
								key.interestOps(change.getOps());
								break;
							case Register:
								break;
						}
					}

					this.changeRequests.clear();
				}

				this.selector.select();

				Iterator<SelectionKey> selectedKeys = selector.selectedKeys().iterator();

				// Loop through all the channel keys that have made a connection.
				while (selectedKeys.hasNext())
				{
					SelectionKey selectedKey = selectedKeys.next();
					selectedKeys.remove();

					if (!selectedKey.isValid())
					{
						continue;
					}

					// Make sure the channel is in the correct state.
					if (selectedKey.isAcceptable())
					{
						this.accept(selectedKey);
					}
					else if (selectedKey.isReadable())
					{
						this.read(selectedKey);
					}
					else if (selectedKey.isWritable())
					{
						this.write(selectedKey);
					}
				}
			}
			catch (IOException e)
			{
				logger.error(e);
			}
		}
	}

	private void write(SelectionKey selectedKey)
		throws IOException
	{
		SocketChannel socketChannel = (SocketChannel) selectedKey.channel();

		synchronized (this.pendingData)
		{
			List queue = (List) this.pendingData.get(socketChannel);

			// Write until there is no more data left
			while (!queue.isEmpty())
			{
				ByteBuffer buffer = (ByteBuffer) queue.get(0);
				logger.trace("Writing to socket - " + socketChannel.socket().getInetAddress() + ":" + socketChannel.socket().getPort());

				socketChannel.write(buffer);

				if (buffer.remaining() > 0)
				{
					// Socket buffer is full
					break;
				}

				queue.remove(0);
			}

			if (queue.isEmpty())
			{
				// We wrote all data, so we are no longer interested in writing on this socket.
				// Switch back to waiting for data.
				selectedKey.interestOps(SelectionKey.OP_READ);
			}
		}
	}

	/**
	 * Handle the new accepted connection.
	 *
	 * @param selectedKey the key in which to accept.
	 *
	 * @throws IOException if we cannot accept the connection.
	 */
	private void accept(SelectionKey selectedKey)
		throws IOException
	{
		// For an accept to be pending the channel must be a server socket channel.
		ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectedKey.channel();

		// Accept the connection and make it non-blocking
		SocketChannel socketChannel = serverSocketChannel.accept();
		logger.debug("Client connected - " + socketChannel.socket().getInetAddress() + ":" + socketChannel.socket().getPort());
		Socket socket = socketChannel.socket();
		socketChannel.configureBlocking(false);

		// Register the new SocketChannel with our selector
		// indicating we'd like to be notified when there's data waiting to be read
		socketChannel.register(this.selector, SelectionKey.OP_READ);
	}

	/**
	 * Reads from the socket channel
	 *
	 * @param selectedKey the key in which to read from.
	 *
	 * @throws java.io.IOException If there is an error when reading.
	 */
	public void read(SelectionKey selectedKey)
		throws IOException
	{
		SocketChannel socketChannel = (SocketChannel) selectedKey.channel();
		this.readBuffer.clear();

		int numberOfBytesRead;
		try
		{
			numberOfBytesRead = socketChannel.read(this.readBuffer);
		}
		catch (IOException ex)
		{
			// The remote forcibly closed the connection, cancel
			// the selection key and close the channel.
			this.gameWorker.notifyClientDisconnected(socketChannel);
			selectedKey.cancel();
			socketChannel.close();
			logger.debug("Client disconnected forcibly - " + socketChannel.socket().getInetAddress() + ":" + socketChannel.socket().getPort());
			return;
		}

		// Remote entity shut the socket down cleanly.
		// Do the same from our end and cancel the channel.
		if (numberOfBytesRead == -1)
		{
			this.gameWorker.notifyClientDisconnected(socketChannel);
			selectedKey.channel().close();
			selectedKey.cancel();
			logger.debug("Client disconnected cleanly - " + socketChannel.socket().getInetAddress() + ":" + socketChannel.socket().getPort());
			return;
		}

		// Make a copy of the data here so we can output it to the logger then send it to the game worker.
		byte[] copyData = new byte[numberOfBytesRead];
		System.arraycopy(this.readBuffer.array(), 0, copyData, 0, numberOfBytesRead);

		logger.trace("Data received - " + socketChannel.socket().getInetAddress() + ":" + socketChannel.socket().getPort() +
		             " - " + new String(copyData));
		this.gameWorker.processData(socketChannel, copyData);
	}

	/**
	 * Send the data back to the client.
	 *
	 * @param socketChannel the socket to send the data back to.
	 * @param data the data to send back.
	 */
	public void send(SocketChannel socketChannel, byte[] data)
	{
		logger.trace("About to send data to socket - " + socketChannel.socket().getInetAddress() + ":" + socketChannel.socket().getPort() + " - " +
		             new String(data));

		synchronized (this.changeRequests)
		{
			// Indicate we want the interest ops set changed
			this.changeRequests.add(new ChangeRequest(socketChannel, ChangeRequestType.ChangeOps, SelectionKey.OP_WRITE));

			// Queue the data we want written
			synchronized (this.pendingData)
			{
				List<ByteBuffer> queue = this.pendingData.get(socketChannel);

				if (queue == null)
				{
					queue = new ArrayList<ByteBuffer>();
					this.pendingData.put(socketChannel, queue);
				}

				queue.add(ByteBuffer.wrap(data));
			}
		}

		// Wake up our selecting thread so it can make the required changes.
		this.selector.wakeup();
	}

	/**
	 * Creates a new non-blocking selector.
	 *
	 * @return the new selector.
	 */
	private Selector initializeSelector()
	{
		logger.trace("Initializing server selector to accept connections");
		Selector selector = null;
		try
		{
			// Create a new selector
			selector = SelectorProvider.provider().openSelector();

			// Create a new non-blocking server socket channel.
			this.serverChannel = ServerSocketChannel.open();
			this.serverChannel.configureBlocking(false);
		}
		catch (IOException e)
		{
			logger.error(e);
		}

		try
		{
			// Bind the server socket to the specified address and port.
			this.serverChannel.socket().bind(new InetSocketAddress(port));
		}
		catch (IOException e)
		{
			logger.error(e);
			this.shutdown("Bind error");
			return null;
		}

		// Register the server socket channel, indicating an interest in accepting new connections.
		try
		{
			this.serverChannel.register(selector, SelectionKey.OP_ACCEPT);
		}
		catch (ClosedChannelException e)
		{
			logger.error(e);
		}

		return selector;
	}

	public void disconnectClientFromServer(SocketChannel socketChannel)
	{
		logger.info("Disconnecting client from server side.");
		// Indicate we want the interest ops set changed
		//this.changeRequests.add(new ChangeRequest(socketChannel, ChangeRequestType.ChangeOps, SelectionKey.OP_READ));
	}
}
